feat(session-bridge): native channels adapter with loopback fallback - #6063
Conversation
Add ChannelRelay, a second Transport adapter on Claude Code's native channels (research preview), served by a stdio MCP channel server (session_bridge.py relay). The relay holds the data dir's lease in place of watch.sh and rings the session with a channel event that carries no page text; the session reads the events through the server's events tool. select_transport picks channels only when the session opted the relay in with --channels or --dangerously-load-development-channels, auth is first-party, and no readable organization policy blocks channels; otherwise it keeps the loopback watcher and states why. Planning carries the regenerated copy but registers no channel server, so its behavior is unchanged. Closes #5855 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dge-channels-adapter # Conflicts: # plugins/planning/.claude-plugin/plugin.json # plugins/planning/CHANGELOG.md
A lease conflict rang the holder name the page server sent, so a token holder
could put arbitrary text into the session's channel message. Rings now carry
fixed text only; a holder matching [A-Za-z0-9._-]{1,64} goes to stderr.
The relay validates the wait answer's shape; a bad answer or any unexpected
error releases the lease and rings a fixed stopped notice, so watch can start
again. The channel server releases its live leases when its input closes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Claude finished @kyle-sexton's task in 7m 14s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 3m 40s —— View job Security review (
|
|
claude-security-review has reviewed this pull request through 83decdb; a later push is reviewed from there. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f3b63de464
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
claude-review has reviewed this pull request through 83decdb; a later push is reviewed from there. |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 51s —— View job Code review (
|
|
Claude finished @kyle-sexton's task in 55s —— View job Security review (
|
…ection main's #6063 released planning 0.65.4; this branch's planning entry moves to 0.65.5 above it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Resolves the conflicts with #6063 (channels adapter) in the session-bridge README and CHANGELOGs. harness-ops moves to 3.6.2, and architecture and education, new carriers of the shared builder and runtime on main, get sync-only bumps. The view server now also ends itself, and its token with it, once no watcher wait has been in flight for 600 seconds, so the token expires with the session (rendered-views rule 9). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No related issue: wave 3 PR 2 of the CI performance program (precise test selection). Refs #3932, #6021. ## Summary `scripts/affected-tests.sh` now selects the suites a change runs or reads, in the change's own language, instead of every suite that mentions a file name anywhere, every shell suite of a touched plugin, and three always-run suites. Over the 895 pull requests merged to main in the 7 days to 9671ece it selects 9,534 suites where main's selector selects 34,365, within 5% of the design model's 9,095, and both real main breaks from the window are still selected. Every Node suite it selects also runs: a Node suite that CI runs through a sibling `.test.sh` brings that wrapper (R9). ## Fix Rules (full text in the script header): - **Same-language edges (R3).** A file in the changed file's language that names it on a code line is a dependent, transitively. - **Another language counts only where the line runs or loads the file (R4):** an interpreter or process API on the line, matched as a word (the `.sh` of `x.sh` is not one), or a path to the file. A chain takes at most one such transition. A changed data file (C#, Markdown, YAML, ...) reaches code of any language that names it without spending the transition. - **Comment lines never count**, in suites and in code. `# shellcheck source=` and JSDoc `@import` / `import()` still count. - **Manifests select no suite through a mention.** plugin.json, marketplace.json, hooks.json, settings.json, package.json, package-lock.json, CHANGELOG.md and LICENSE reach a suite only through R1, R2 or a declared scope; their gates own them. - **Ambiguous names count only when the mention resolves.** This covers basenames two or more files carry, plus README.md, SKILL.md, AGENTS.md, CLAUDE.md and index.md. A mention resolves when it comes from the file's own directory; when it is a bare name from a directory above the file with no other file of that name below; when it ends in the file's shortest unique path suffix of two or more components; or when it is a path relative to a directory below the root that holds both files and has a directory in it (`$PLUGIN_DIR/skills/interview/SKILL.md`). A name-only path (`$SKILL_DIR/SKILL.md`, `$T/README.md`) or a root-relative one (`$ROOT/.github/workflows/ci.yml`) does not resolve, because tests build those paths under temporary directories; the suites the strace saw reading such files declare them. - **No Python import rule.** `import foo` does not name `foo.py`, as in design rules S1-S9. A module that only an import reaches is unmapped and falls back to the Python corpus (S9). - **Declared scopes replace R8's whole-plugin rule and the always list (S7).** A suite that reads files it never names declares them in its leading comment block, before any code or docstring: `# test-scope: <glob> [<glob>...]`, one or more lines. 87 suites carry 132 globs, seeded from an strace of every suite. `scripts/affected-tests.test.sh` also declares the live files its LIVE cases find by glob (the github `advise` and planning `interview` skill bodies, the autonomy reference docs), so renaming or deleting one runs it; its reference-YAML case runs in a fixture on probe files, so that YAML stays unmapped. A changed suite whose glob matches no file fails the run. `scripts/affected-tests-always.txt` is deleted, and `--with-always` is accepted and does nothing. - **A wrapped Node suite brings its wrapper (R9).** A selected `<stem>.test.js` or `<stem>.test.mjs` whose directory holds `<stem>.test.sh` selects that wrapper too, after every other rule and before `--shard`. CI runs such a suite only through the wrapper (`scripts/run-outside-node-suites.sh` reports it `OWNED` and runs nothing), and the walk stops at a reached suite, so a change reaching `exec-bash.resolver.test.mjs` through `lib/exec-bash.mjs` used to select the suite, not the wrapper, and CI ran neither. - **An unmapped file runs only its own language's suites.** `--unmapped-corpus` keeps the report, adds that language's corpus and exits 4. Wiring it into `ci.yml` is PR 3's job. - **No-suite list.** `plugins/*/evals/*` replaces the eval fixture entries. The Python module entries main listed (`plugin_cache_versions.py`, `discover.py`, `docs_crosscheck.py`, the `session_bridge.py` copies) are removed, so a change to one is unmapped and falls back to the Python corpus instead of selecting nothing. `plugins/performance/lib/spawn_noise.py` (a copy nothing imports) is added. - **`--replay <range> [--against <ref>]`** reruns the selector on each first-parent commit against its parent in a scratch clone, with this tree's no-suite list and declared scopes (handed to older commits through `AFFECTED_TESTS_SCOPES`), and, with `--against`, prints only the suites the two selectors disagree on, `<ref>` using its own headers. - **Releases.** The 30 plugins whose suites gained a header get a patch bump and a CHANGELOG entry naming those suites; nothing they run changed. ## Verification ### Replay: 7 days of merged pull requests Every first-parent commit on main from 2026-09-26 to 9671ece (900; 895 change a file), selected against its parent three ways: main's selector at 9671ece, this PR's selector, and the design's model (`selmodel.py`, the script that produced the design's section 3 figures) re-run on the same commits. | measure (895 PRs) | main | this PR | design model | |---|---|---|---| | suites per PR, p50 / p90 / p95 / max | 22 / 83 / 142 / 523 | 5 / 24 / 33 / 281 | 4 / 23 / 31 / 273 | | suites selected, total | 34,365 | 9,534 | 9,095 | | total with the language-scoped unmapped fallback (S9, PR 3) | 38,094 | 15,290 | 27,360 | | total as CI runs it today (unmapped: whole shell corpus) | 43,852 | 21,686 | 32,445 | | shell suites selected | 32,420 | 7,946 | 7,352 | | PRs with an unmapped file | 22 | 26 | 48 | | PRs with no shell change that start shell suites | 491 of 491 (10,269) | 351 of 491 (1,560) | 312 of 491 (1,361) | | PRs changing no code that start any suite | 356 of 356 (7,181) | 233 of 356 (974) | 203 of 357 (856) | | PRs selecting no suite | 0 | 127 | 157 | The design's section 3 figures (p50 3, p95 28, 7,348 total, 13,371 with the fallback) came from a different window, 826 PRs to 2026-10-02 20:52Z. The same model gives the last column on this window, so that column is the target the 5% bar applies to. ### Design targets vs measured | | this PR | design model | difference | |---|---|---|---| | suites selected, total | 9,534 | 9,095 | +439 (+4.8%) | | p50 / p90 | 5 / 24 | 4 / 23 | +1 / +1 | | p95 | 33 | 31 | +2 (+6.5%) | | max | 281 | 273 | +8 (+2.9%) | The total is within the 5% bar. p50 is 1 suite over the model and p95 2, and the declared scopes account for both: selections only this PR makes are 713 through a declared scope, 41 wrappers (R9), 31 through a mention and 16 siblings; selections only the model makes are 362 (its guessed plugin scans 190, its interpreter test matching the `.sh` of a file name 116, siblings 56). Without the 713 declared-scope selections, p95 would be 30. The declared scopes are what the strace saw the suites read, plus the live files `scripts/affected-tests.test.sh` reads by glob (18 of the 713: edits to the github `advise` and planning `interview` skill bodies and the autonomy reference docs). What dropping the Python import rule cost, against the previous head: 903 (PR, suite) selections over 75 suites, 264 of which main also made; the strace saw the suite read a changed file in 60 of them. Where nothing else maps the module (`discover.py`, `docs_crosscheck.py`, `plugin_cache_versions.py`), the change is unmapped and the S9 fallback runs the Python corpus. Where the module maps through a sibling or a mention (`hygiene.py`, `destructive_guard.py`), the suites that only import it are not selected; the design accepts that and catches it with the twice-daily full run and the trace audit (PR 4). ### The C# fixture case `plugins/code-metrics/scripts/fixtures/sources/CmSample.cs` goes from 14 suites to 7. `dispatch.test.sh`, `audit-complexity.test.sh` and `audit-type-debt.test.sh` name the file. `audit-coverage.test.sh`, `audit-duplication.test.sh` and `audit-size.test.sh` declare `plugins/code-metrics/scripts/fixtures/*`, which the strace saw them read. `setup-check.test.sh` sits beside `setup-check.sh`, which copies the plugin's `scripts/`. The design predicted 4; the three declared scopes make the difference. ### The two real main breaks are still selected - 88dd7e1 selects `plugins/github/github.test.sh` (its header declares `plugins/github/*`) and `plugins/planning/tests/interview-defenses.test.sh`. - e544012 selects `plugins/planning/tests/interview-defenses.test.sh`: `$PLUGIN_DIR/skills/interview/SKILL.md` resolves to the changed file. The suite also pins both against the live tree. ### Trace audit of every drop Every suite ran under strace to record its file reads. Of 25,352 (PR, suite) pairs this PR drops against main, over 700 suites, the strace saw the suite read a changed file in 1,004 pairs over 53 suites: - **Manifest identity reads:** `install_state`, `sync-run`, the planning `surface`/`watch` suites and the `*-format` hook suites read a plugin's name or version from `plugin.json`, or Node reads the root `package.json` while resolving modules. The root package files are pins that PR 3 routes to the Node lanes (S8). - **Live gates CI also runs as whole-tree gate steps:** `check-loop-lane-floor-drift`, `check-fixture-git-isolation`, `check-summary-reader-parity`. - **`scripts/affected-tests.test.sh`:** its LIVE cases run the selector, whose `git grep` reads every file. - **Plugin copies and walks:** `abort-boundary`, `check-guardrails-ps-differential` and `test_kill_switch_probe.py` read a README or changelog, which does not change what they test. - **Python imports:** the 60 pairs above, where a test imports a changed module that something else maps. - **Relations only today's tree has**, since the trace ran on today's tree and each PR replays on its own: `interview-defenses.test.sh` began naming `context/surface.md` after 7 of its PRs. `check-prerequisite-probes.test.sh` has been deleted since. The full list, each suite with its PR count, its trace verdict and main's reasons for selecting it, is in [this comment](#6059 (comment)) (57 KB, too large for this body). ### Every selected Node suite runs (R9) The figures above are the previous head's per-PR selections with R9 applied. A real replay of this head's selector over the same 900 commits, with the same inputs as the previous head's replay, removes nothing and adds exactly those 41 (PR, suite) selections over 33 PRs, each the `.test.sh` wrapper of a Node suite already selected: `lib/exec-bash.resolver.test.sh` 31, `plugins/guardrails/hooks/exec-bash.resolver.test.sh` 7, `plugins/autonomy/skills/setup/scripts/resolve-prerequisites.fixtures.test.sh` 3. No PR's exit code or unmapped set changes; three `--unmapped-corpus` runs also gain the wrappers of the Node suites their corpus adds. Selected Node suites that CI runs nowhere (outside the registered packages and the four sub-projects, with a wrapper that is not selected): 41 (PR, suite) pairs over 33 PRs at the previous head, 5 of which main covered by selecting the wrapper; 0 at this head. On today's tree, `lib/exec-bash.mjs` plus `plugins/guardrails/hooks/exec-bash.mjs` selects both `exec-bash.resolver.test.mjs` suites and both wrappers, and `run-outside-node-suites.sh --paths` on the two Node suites (CI's exit-3 branch) reports each `OWNED` by its wrapper and exits 0. The workflow scripts of review, planning, testing, discovery and multi-agent and the autonomy `fixture-harness.mjs` and `resolve-prerequisites.mjs` select 9 wrapped Node suites, each with its wrapper. ### Local runs WSL (Ubuntu 26.04) at the head, main merged: - `scripts/affected-tests.test.sh`: PASS=134 FAIL=0 (the 4 Python-import cases are gone; the R8 cases now build suites with headers, including a declaration below code that declares nothing and a stale glob that fails only the run changing its suite; the R9 case, a Node suite reached through a mention, fails on the previous head's selector, 133/1) - `scripts/lib/gate-entry.test.sh`: 33/0 - Headers read back from the 87 suites equal the former list entry for entry, plus the three globs `scripts/affected-tests.test.sh` adds for the live files it reads. - The selector on `plugins/github/skills/advise/SKILL.md`, `plugins/planning/skills/interview/SKILL.md` and an autonomy reference doc selects `scripts/affected-tests.test.sh` through its header; the reference YAML (`plugins/toolchain/reference/ecosystems/go.yaml`, `docs/conventions/ecosystem-commands/examples/go.yaml`) stays UNMAPPED at exit 1. - shellcheck clean on the selector and the 81 changed shell suites; the pinned ruff check passes on the 6 changed Python suites. - `check-changelog-parity.sh` `--check`, `--check-bump`, `--check-preserved` and `--check-order` pass against origin/main. Where main released a plugin this PR also releases (planning in #6063; animation in #6081; code-metrics, harness-ops, repo-hygiene, session-flow and source-control in #6065; actionlint, animation, autonomy, context-guard, guardrails, harness-ops, source-control, speech and testing in #6064; speech in #6082; review in #6018; discovery in #6088), this PR's entry sits one patch above main's. - Main's #6064 deleted `scripts/lib/sync-cluster.sh`, the only file the `scripts/lib/sync-*.sh` glob in `scripts/affected-tests.test.sh`'s header matched; the glob is dropped, since the selector fails (exit 2) on any diff that changes a suite declaring a glob that matches nothing, as it did on the merge commit alone. ## Related - #3932: the CI performance program. - #6021 (wave 3 PR 1, `ci.yml` job rename) has landed. It edited `scripts/affected-tests-always.txt`, and this PR keeps that file deleted. - PR 3 should: - run `--unmapped-corpus` in place of the whole-shell-corpus fallback, which gives the 15,290 figure above; - route the pins (root `package*.json`, `.node-version`, Python pins) to their lanes (S8); - drop `--with-always` from `ci.yml`; - update the `ci.yml` comment that names `affected-tests-always.txt`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ksextonmelodic <ksextonmelodic@gmail.com>
Closes #5855
Summary
session-bridge gets a second adapter on Claude Code's native channels (research preview), plus a
selection function that picks it only when channels can deliver and otherwise keeps the loopback
watcher and says why. Wave B4 follow-up of #5835, after #5907 extracted the port.
No app opts in yet. Planning carries the regenerated copy but registers no channel server and
calls neither the relay nor the selection, so the interview page, the watcher and
round.shareunchanged. The Claude-interactive tier stays closed: no rendered-views doc or page changes here.
Fix
ChannelRelay(Transport): the channels adapter. Inside the session's channel server itlong-polls the page server's
/api/waitwith the token from the 0600 env file and holds thelease in place of
watch.sh. Its log is the batch the page server delivered that the sessionhas not read. On new events it rings the session once; it does not ring again for the same
events. A 409, a changed token, the page server stopping, or 12 unreachable polls end it with a
final
stopped="1"ring naming the reason.ChannelServer(session_bridge.py relay): a stdlib stdio MCP server declaringexperimental["claude/channel"]and three tools takingdata_dir:watch,events,unwatch.data_dir,seqandcount, never page text, so nothing fromthe page reaches the session as a channel message (rendered-views rule 9, bullet 2).
eventsreturns the batch inwatch.sh's line shape with the data note, andnextis theapp's apply command.
select_transport(entries)(session_bridge.py select <entry>...): returns{"transport", "reason"}. It picks channels only when every check passes; an unreadable inputcounts as failed. The checks, in order:
--channelsor--dangerously-load-development-channels. It reads/proc, elseps; on Windows the flagscannot be read, so the result is loopback.
claude auth status --jsonreports logged in andfirstParty.has
channelsEnabled: true. With no source, ateamorenterpriseplan keeps loopback.--channelsmust be onallowedChannelPlugins.degradelines for
claude, Anthropic auth, the session opt-in and org enablement. Auth and orgenablement are not prerequisites-checker kinds, so
select_transportreports them. A plugin thatregisters the relay adds the
clauderow to itsprerequisites.json.planning0.65.2 with a CHANGELOG entry for the regenerated copy.Verification
python3 -m unittest test_session_bridge(lib/session-bridge): 54 tests OK (36 before). Thenew tests cover:
non-first-party auth, a team org without
channelsEnabled, a policy without it, and--channelsoff the allowlistinitializedeclares the channel, a ringcarries no page text,
eventsreturns the batch and the apply command, there is no re-ringfor read events,
unwatchreleases the lease, a released lease ringsstopped, andwatchwithout a server fails
selectfrom a shell not launched with a channels flag returnsloopback ("not started with --channels ... naming ..."). From a parent whose argv carries
--dangerously-load-development-channels plugin:x@y, it returns channels using the realclaude auth status.python3 -m unittest test_server test_round test_exporters test_schema: 559 OK (1 skipped)watch.test.sh27/0surface.test.sh479/0 (1 skip), with the browser suitesinterview-defenses172/0,reattach-slice23/0 andstandards-binding9/0interview-surface-decision-mirror,plan-panel(22/0),check-open-questions,check-plan-outcome,eval-scaffoldandgoal-condition-lengthall okwatch.test.sh27/0 andplan-panel22/0. The four planning Python
suites still give 559 OK (1 skipped).
sync-shared-copies.sh --check(260 copies match) and--check-bump origin/main(planningbumped).
check-declared-prerequisites.mjsis clean: theclaudecall carriesprereq-ok.check-cross-plugin-source-drift.sh --checkis clean.--check-bump,--check-orderand--check-preservedmodes.registering the relay and a session started with the development flag, which belongs to the
first adopter (C6).
Related
Claude-interactive tier opens only when rendered-views rule 9 is met, which this PR does not
change.
🤖 Generated with Claude Code